{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-builder",
  "title": "Filter Builder",
  "description": "A filter builder for data tables: field, operator and value rows applied to the URL, built on @querycn/filter-react.",
  "dependencies": [
    "@querycn/filter-core@^0.3.0",
    "@querycn/filter-react@^0.3.0",
    "cn",
    "lucide-react",
    "radix-ui"
  ],
  "registryDependencies": [
    "badge",
    "button",
    "calendar",
    "input",
    "popover",
    "select",
    "command"
  ],
  "files": [
    {
      "path": "registry/radix/filter/filter-builder.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Direction } from \"radix-ui\"\nimport { useAppliedFilter, useFilterActions } from \"@querycn/filter-react\"\nimport { ListFilterIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\nimport { Badge } from \"@/registry/radix/ui/badge\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/radix/ui/popover\"\nimport {\n  FilterBuilderPanel,\n  type FilterBuilderPanelProps,\n} from \"@/registry/radix/filter/filter-builder-panel\"\n\nexport interface FilterBuilderProps extends Omit<\n  FilterBuilderPanelProps,\n  \"onApply\" | \"className\"\n> {\n  className?: string\n  /** Classes for the popover around the panel. */\n  contentClassName?: string\n}\n\n/** A button showing how many filters apply; it opens the panel. Closing without Apply drops the edits. */\nexport function FilterBuilder({\n  className,\n  contentClassName,\n  ...panelProps\n}: FilterBuilderProps) {\n  const dir = Direction.useDirection()\n  const { activeCount, messages } = useAppliedFilter()\n  const { addRule, discard } = useFilterActions()\n  const [open, setOpen] = React.useState(false)\n  const handleOpenChange = (next: boolean) => {\n    setOpen(next)\n    if (!next) discard()\n    // Starts an empty filter with a rule to fill in.\n    else if (activeCount === 0) addRule()\n  }\n\n  return (\n    <Popover open={open} onOpenChange={handleOpenChange}>\n      <PopoverTrigger asChild>\n        <Button variant=\"outline\" size=\"sm\" className={className}>\n          <ListFilterIcon />\n          {messages.actions.open}\n          {activeCount > 0 && (\n            <>\n              <Badge variant=\"secondary\" aria-hidden>\n                {activeCount}\n              </Badge>\n              <span className=\"sr-only\">\n                {messages.counts.activeFilters(activeCount)}\n              </span>\n            </>\n          )}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        dir={dir}\n        align=\"start\"\n        className={cn(\"w-auto max-w-[calc(100vw-2rem)] p-3\", contentClassName)}\n      >\n        <FilterBuilderPanel {...panelProps} onApply={() => setOpen(false)} />\n      </PopoverContent>\n    </Popover>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-builder.tsx"
    },
    {
      "path": "registry/radix/filter/filter-builder-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useFilter } from \"@querycn/filter-react\"\nimport { PlusIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport { FilterJoinSelect } from \"@/registry/radix/filter/filter-join-select\"\nimport {\n  FilterRuleRow,\n  type FilterRuleRowProps,\n} from \"@/registry/radix/filter/filter-rule-row\"\n\nfunction lostFocus() {\n  const active = document.activeElement\n  return (\n    !active ||\n    active === document.body ||\n    !active.isConnected ||\n    (active as HTMLButtonElement).disabled === true\n  )\n}\n\nexport interface FilterBuilderPanelProps {\n  valueInput?: FilterRuleRowProps[\"valueInput\"]\n  /** Offers AND/OR on the second rule. Defaults to `true`; otherwise rules keep the current join. */\n  allowJoinToggle?: boolean\n  /** Runs after Apply, e.g. to close a popover. */\n  onApply?: () => void\n  className?: string\n}\n\n/** The draft rules with Add, Clear all and Apply. Enter in a text field applies. */\nexport function FilterBuilderPanel({\n  valueInput,\n  allowJoinToggle = true,\n  onApply,\n  className,\n}: FilterBuilderPanelProps) {\n  const {\n    state,\n    isDirty,\n    canAddRule,\n    messages,\n    addRule,\n    setJoin,\n    apply,\n    reset,\n  } = useFilter()\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const addRef = React.useRef<HTMLButtonElement>(null)\n  const added = React.useRef(false)\n  const ids = state.rules.map((rule) => rule.id).join()\n  const previousIds = React.useRef(ids)\n\n  React.useEffect(() => {\n    if (ids === previousIds.current) return\n    previousIds.current = ids\n    if (added.current) {\n      added.current = false\n      const rows = listRef.current?.querySelectorAll(\n        \"[data-slot=filter-rule-row]\"\n      )\n      rows?.[rows.length - 1]?.querySelector(\"button\")?.focus()\n    } else if (lostFocus()) {\n      // A removed row, Clear all, or Apply (rows remount under new ids) dropped it.\n      addRef.current?.focus()\n    }\n  }, [ids])\n\n  return (\n    <form\n      data-slot=\"filter-builder-panel\"\n      aria-label={messages.actions.open}\n      className={cn(\"flex flex-col gap-3\", className)}\n      onSubmit={(event) => {\n        event.preventDefault()\n        // React events cross portals, so an enclosing form would submit too.\n        event.stopPropagation()\n        apply()\n        onApply?.()\n      }}\n    >\n      {state.rules.length === 0 ? (\n        <p className=\"text-sm text-muted-foreground\">{messages.empty.rules}</p>\n      ) : (\n        <div ref={listRef} className=\"flex flex-col gap-2\">\n          {state.rules.map((rule, index) => (\n            <div key={rule.id} className=\"flex items-start gap-2\">\n              <div className=\"flex h-7 w-16 shrink-0 items-center text-sm text-muted-foreground\">\n                {index === 0 ? (\n                  messages.join.where\n                ) : index === 1 && allowJoinToggle ? (\n                  <FilterJoinSelect\n                    value={state.join}\n                    onValueChange={setJoin}\n                  />\n                ) : (\n                  messages.join[state.join]\n                )}\n              </div>\n              <FilterRuleRow\n                rule={rule}\n                valueInput={valueInput}\n                className=\"flex-1\"\n              />\n            </div>\n          ))}\n        </div>\n      )}\n      <div className=\"flex items-center gap-2\">\n        <Button\n          ref={addRef}\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"sm\"\n          disabled={!canAddRule}\n          onClick={() => {\n            added.current = true\n            addRule()\n          }}\n        >\n          <PlusIcon />\n          {messages.actions.addRule}\n        </Button>\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"ml-auto\"\n          disabled={state.rules.length === 0 && !isDirty}\n          onClick={reset}\n        >\n          {messages.actions.clearAll}\n        </Button>\n        <Button type=\"submit\" size=\"sm\" disabled={!isDirty}>\n          {messages.actions.apply}\n        </Button>\n      </div>\n    </form>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-builder-panel.tsx"
    },
    {
      "path": "registry/radix/filter/filter-join-select.tsx",
      "content": "\"use client\"\n\nimport type { Join } from \"@querycn/filter-core\"\nimport { useFilterActions } from \"@querycn/filter-react\"\n\nimport { cn } from \"cn\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/registry/radix/ui/select\"\n\nexport interface FilterJoinSelectProps {\n  value: Join\n  onValueChange: (join: Join) => void\n  className?: string\n}\n\nexport function FilterJoinSelect({\n  value,\n  onValueChange,\n  className,\n}: FilterJoinSelectProps) {\n  const { messages } = useFilterActions()\n  return (\n    <Select\n      value={value}\n      onValueChange={(next) => {\n        // Inside a form Radix mirrors the value into a hidden <select>, which can report \"\".\n        if (next === \"and\" || next === \"or\") onValueChange(next)\n      }}\n    >\n      <SelectTrigger\n        size=\"sm\"\n        aria-label={messages.join.toggle}\n        className={cn(\"w-full\", className)}\n      >\n        <SelectValue />\n      </SelectTrigger>\n      <SelectContent position=\"popper\" align=\"start\">\n        <SelectItem value=\"and\">{messages.join.and}</SelectItem>\n        <SelectItem value=\"or\">{messages.join.or}</SelectItem>\n      </SelectContent>\n    </Select>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-join-select.tsx"
    },
    {
      "path": "registry/radix/filter/filter-rule-warnings.tsx",
      "content": "\"use client\"\n\nimport { Direction } from \"radix-ui\"\nimport type { FilterRule } from \"@querycn/filter-core\"\nimport {\n  useAppliedFilter,\n  useRuleWarnings,\n  type RuleWarningKey,\n} from \"@querycn/filter-react\"\nimport { TriangleAlertIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/radix/ui/popover\"\n\n/** A warning icon that shows what may go wrong when pressed, so it works on touch too. Nothing when there are none. */\nexport function FilterRuleWarnings({\n  warnings,\n  className,\n}: {\n  warnings: readonly RuleWarningKey[]\n  className?: string\n}) {\n  const dir = Direction.useDirection()\n  const { messages } = useAppliedFilter()\n  if (warnings.length === 0) return null\n  const texts = warnings.map((key) => messages.warnings[key])\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon-xs\"\n          aria-label={texts.join(\". \")}\n          className={cn(\n            \"text-amber-600 hover:text-amber-600 aria-expanded:text-amber-600 dark:text-amber-500 dark:hover:text-amber-500 dark:aria-expanded:text-amber-500\",\n            className\n          )}\n        >\n          <TriangleAlertIcon />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        dir={dir}\n        side=\"top\"\n        className=\"w-auto max-w-xs gap-1 p-2 text-xs\"\n      >\n        {texts.map((text) => (\n          <p key={text}>{text}</p>\n        ))}\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n/** Warnings for a draft rule. It re-renders with the draft, so the memoized row around it doesn't. */\nexport function FilterDraftRuleWarnings({ rule }: { rule: FilterRule }) {\n  return <FilterRuleWarnings warnings={useRuleWarnings(rule)} />\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-rule-warnings.tsx"
    },
    {
      "path": "registry/radix/filter/filter-chips.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  findField,\n  type FieldDefinition,\n  type FilterRule,\n  type RuleIssue,\n} from \"@querycn/filter-core\"\nimport { useAppliedFilter } from \"@querycn/filter-react\"\nimport { XIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport { FilterRuleWarnings } from \"@/registry/radix/filter/filter-rule-warnings\"\nimport { useRuleSummary } from \"@/registry/shared/filter/filter-rule-summary\"\n\nconst NO_ISSUES: readonly RuleIssue[] = []\n\n/** The applied rules as chips; each one's × removes it right away. */\nexport function FilterChips({ className }: { className?: string }) {\n  const { state, context, messages, ruleIssues, removeRule } =\n    useAppliedFilter()\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const focusAt = React.useRef<number | null>(null)\n\n  // The removed chip had focus; hand it to the chip that took its place.\n  React.useEffect(() => {\n    if (focusAt.current === null) return\n    const buttons = listRef.current?.querySelectorAll<HTMLElement>(\n      \"[data-slot=filter-chip-remove]\"\n    )\n    buttons?.[Math.min(focusAt.current, buttons.length - 1)]?.focus()\n    focusAt.current = null\n  }, [state.rules])\n\n  if (state.rules.length === 0) return null\n  return (\n    <div\n      ref={listRef}\n      data-slot=\"filter-chips\"\n      className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n    >\n      {state.rules.map((rule, index) => {\n        const field = findField(context, rule.field)\n        if (!field) return null\n        return (\n          <React.Fragment key={rule.id}>\n            {index > 0 && (\n              <span className=\"text-xs text-muted-foreground\">\n                {messages.join[state.join]}\n              </span>\n            )}\n            <FilterChip\n              rule={rule}\n              field={field}\n              issues={ruleIssues[rule.id] ?? NO_ISSUES}\n              onRemove={() => {\n                focusAt.current = index\n                removeRule(rule.id)\n              }}\n            />\n          </React.Fragment>\n        )\n      })}\n    </div>\n  )\n}\n\nfunction FilterChip({\n  rule,\n  field,\n  issues,\n  onRemove,\n}: {\n  rule: FilterRule\n  field: FieldDefinition\n  issues: readonly RuleIssue[]\n  onRemove: () => void\n}) {\n  const { messages } = useAppliedFilter()\n  const summary = useRuleSummary(rule, field)\n  const text = [summary.field, summary.operator, summary.value]\n    .filter(Boolean)\n    .join(\" \")\n\n  return (\n    <span\n      data-slot=\"filter-chip\"\n      className=\"inline-flex h-7 max-w-full items-center gap-0.5 rounded-md border bg-muted/50 pr-0.5 pl-2 text-xs\"\n    >\n      <span\n        title={text}\n        className={cn(\n          \"truncate\",\n          // The backend ignores it, so it reads as crossed out.\n          issues.includes(\"unsupported\") && \"text-muted-foreground line-through\"\n        )}\n      >\n        <span className=\"font-medium\">{summary.field}</span>{\" \"}\n        <span className=\"text-muted-foreground\">{summary.operator}</span>\n        {summary.value !== undefined && ` ${summary.value}`}\n      </span>\n      <FilterRuleWarnings warnings={issues} />\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon-xs\"\n        data-slot=\"filter-chip-remove\"\n        aria-label={`${messages.actions.removeRule}: ${text}`}\n        onClick={onRemove}\n      >\n        <XIcon />\n      </Button>\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-chips.tsx"
    },
    {
      "path": "registry/radix/filter/filter-combobox.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Direction } from \"radix-ui\"\nimport type { SelectOption } from \"@querycn/filter-core\"\nimport { useFilterActions } from \"@querycn/filter-react\"\nimport { CheckIcon, ChevronsUpDownIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/registry/radix/ui/command\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/radix/ui/popover\"\n\nexport interface FilterComboboxProps {\n  id?: string\n  \"aria-label\": string\n  /** Trigger content; `undefined` shows the placeholder. */\n  label: React.ReactNode | undefined\n  placeholder: string\n  /** Already filtered for `search`. */\n  options: readonly SelectOption[]\n  selected: readonly string[]\n  /** Picks toggle values and the list stays open. */\n  multiple?: boolean\n  onSelectedChange: (values: string[]) => void\n  search: string\n  onSearchChange: (search: string) => void\n  emptyText: string\n  loading?: boolean\n  error?: boolean\n  onRetry?: () => void\n  onOpenChange?: (open: boolean) => void\n  className?: string\n}\n\n/** A searchable select of one or several values; the caller does the filtering. */\nexport function FilterCombobox({\n  id,\n  \"aria-label\": ariaLabel,\n  label,\n  placeholder,\n  options,\n  selected,\n  multiple = false,\n  onSelectedChange,\n  search,\n  onSearchChange,\n  emptyText,\n  loading = false,\n  error = false,\n  onRetry,\n  onOpenChange,\n  className,\n}: FilterComboboxProps) {\n  const dir = Direction.useDirection()\n  const { messages } = useFilterActions()\n  const [open, setOpen] = React.useState(false)\n  const handleOpenChange = (next: boolean) => {\n    setOpen(next)\n    // Cleared on open, not close, so the list doesn't jump during the exit animation.\n    if (next) onSearchChange(\"\")\n    onOpenChange?.(next)\n  }\n\n  return (\n    <Popover open={open} onOpenChange={handleOpenChange}>\n      <PopoverTrigger asChild>\n        <Button\n          id={id}\n          variant=\"outline\"\n          size=\"sm\"\n          role=\"combobox\"\n          aria-expanded={open}\n          aria-label={ariaLabel}\n          className={cn(\"w-40 justify-between text-sm font-normal\", className)}\n        >\n          <span\n            className={cn(\n              \"truncate\",\n              label === undefined && \"text-muted-foreground\"\n            )}\n          >\n            {label ?? placeholder}\n          </span>\n          <ChevronsUpDownIcon className=\"text-muted-foreground\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent dir={dir} className=\"w-56 p-0\" align=\"start\">\n        <Command shouldFilter={false}>\n          <CommandInput\n            placeholder={messages.placeholders.search}\n            value={search}\n            onValueChange={onSearchChange}\n          />\n          <CommandList aria-multiselectable={multiple || undefined}>\n            {error ? (\n              <div\n                role=\"alert\"\n                className=\"flex flex-col items-center gap-2 py-4 text-sm text-muted-foreground\"\n              >\n                {messages.errors.loadOptions}\n                <Button variant=\"outline\" size=\"xs\" onClick={onRetry}>\n                  {messages.actions.retry}\n                </Button>\n              </div>\n            ) : loading ? (\n              // Also shown over the previous list while the next search loads.\n              <div\n                role=\"status\"\n                className=\"py-2 text-center text-xs text-muted-foreground\"\n              >\n                {messages.loading}\n              </div>\n            ) : (\n              <CommandEmpty>{emptyText}</CommandEmpty>\n            )}\n            <CommandGroup>\n              {options.map((option) => {\n                const checked = selected.includes(option.value)\n                return (\n                  <CommandItem\n                    key={option.value}\n                    value={option.value}\n                    // Shows the item's own check mark; several values get a checkbox instead.\n                    data-checked={!multiple && checked}\n                    // cmdk's aria-selected marks the highlighted item, not a picked one.\n                    aria-checked={multiple ? checked : undefined}\n                    onSelect={() => {\n                      if (multiple) {\n                        onSelectedChange(\n                          checked\n                            ? selected.filter((v) => v !== option.value)\n                            : [...selected, option.value]\n                        )\n                      } else {\n                        onSelectedChange([option.value])\n                        handleOpenChange(false)\n                      }\n                    }}\n                  >\n                    {multiple && (\n                      <span\n                        aria-hidden\n                        className={cn(\n                          \"flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input\",\n                          checked &&\n                            \"border-primary bg-primary text-primary-foreground\"\n                        )}\n                      >\n                        {checked && <CheckIcon className=\"size-3\" />}\n                      </span>\n                    )}\n                    <span className=\"truncate\">{option.label}</span>\n                  </CommandItem>\n                )\n              })}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-combobox.tsx"
    },
    {
      "path": "registry/radix/filter/filter-field-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { filterOptions, type FieldDefinition } from \"@querycn/filter-core\"\nimport { useFilterActions } from \"@querycn/filter-react\"\n\nimport { FilterCombobox } from \"@/registry/radix/filter/filter-combobox\"\n\nexport interface FilterFieldSelectProps {\n  /** The rule's field; shown even when it isn't in `fields`. */\n  field: FieldDefinition | undefined\n  fields: readonly FieldDefinition[]\n  onFieldChange: (name: string) => void\n  className?: string\n}\n\nexport function FilterFieldSelect({\n  field,\n  fields,\n  onFieldChange,\n  className,\n}: FilterFieldSelectProps) {\n  const { messages } = useFilterActions()\n  const [search, setSearch] = React.useState(\"\")\n  const options = React.useMemo(\n    () => fields.map((f) => ({ label: f.label, value: f.name })),\n    [fields]\n  )\n\n  return (\n    <FilterCombobox\n      aria-label={messages.placeholders.field}\n      label={field?.label}\n      placeholder={messages.placeholders.field}\n      // Matches ignoring accents, like the rest of the filter.\n      options={filterOptions(options, search)}\n      selected={field ? [field.name] : []}\n      onSelectedChange={([name]) => {\n        if (name !== undefined) onFieldChange(name)\n      }}\n      search={search}\n      onSearchChange={setSearch}\n      emptyText={messages.empty.fields}\n      className={className}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-field-select.tsx"
    },
    {
      "path": "registry/radix/filter/filter-operator-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport type { OperatorId } from \"@querycn/filter-core\"\nimport { useFilterActions, type OperatorOption } from \"@querycn/filter-react\"\n\nimport { cn } from \"cn\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/registry/radix/ui/select\"\n\nexport interface FilterOperatorSelectProps {\n  value: OperatorId | null\n  operators: readonly OperatorOption[]\n  onValueChange: (operator: OperatorId) => void\n  /** Id of the element to focus once an operator is picked, e.g. the value input. */\n  focusAfterSelect?: string\n  disabled?: boolean\n  className?: string\n}\n\nexport function FilterOperatorSelect({\n  value,\n  operators,\n  onValueChange,\n  focusAfterSelect,\n  disabled,\n  className,\n}: FilterOperatorSelectProps) {\n  const { messages } = useFilterActions()\n  // Typeahead on the closed trigger also changes the value; only a pick from the open list moves focus.\n  const open = React.useRef(false)\n  const picked = React.useRef(false)\n\n  return (\n    <Select\n      value={value ?? \"\"}\n      onOpenChange={(next) => {\n        open.current = next\n      }}\n      onValueChange={(next) => {\n        // Inside a form Radix mirrors the value into a hidden <select>, which can report \"\".\n        if (!operators.some((o) => o.id === next && o.supported)) return\n        picked.current = open.current\n        onValueChange(next)\n      }}\n      disabled={disabled || operators.length === 0}\n    >\n      <SelectTrigger\n        size=\"sm\"\n        aria-label={messages.placeholders.operator}\n        className={cn(\"w-44\", className)}\n      >\n        <SelectValue placeholder={messages.placeholders.operator} />\n      </SelectTrigger>\n      <SelectContent\n        position=\"popper\"\n        align=\"start\"\n        onCloseAutoFocus={(event) => {\n          const target =\n            picked.current && focusAfterSelect\n              ? document.getElementById(focusAfterSelect)\n              : null\n          picked.current = false\n          if (target) {\n            event.preventDefault()\n            target.focus()\n          }\n        }}\n      >\n        {operators.map((operator) => (\n          <SelectItem\n            key={operator.id}\n            value={operator.id}\n            disabled={!operator.supported}\n          >\n            {operator.label}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-operator-select.tsx"
    },
    {
      "path": "registry/radix/filter/filter-rule-row.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport type {\n  Arity,\n  FieldDefinition,\n  FilterRule,\n  FilterValue,\n} from \"@querycn/filter-core\"\nimport { useFilterActions, useFilterRule } from \"@querycn/filter-react\"\nimport { XIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport { FilterFieldSelect } from \"@/registry/radix/filter/filter-field-select\"\nimport { FilterOperatorSelect } from \"@/registry/radix/filter/filter-operator-select\"\nimport { FilterDraftRuleWarnings } from \"@/registry/radix/filter/filter-rule-warnings\"\nimport { FilterValueInput } from \"@/registry/radix/filter/filter-value-input\"\n\nexport interface FilterValueSlotProps {\n  /** Put on the first focusable element; it gets focus after an operator is picked. */\n  id: string\n  rule: FilterRule\n  field: FieldDefinition\n  arity: Exclude<Arity, \"none\">\n  setValue: (value: FilterValue) => void\n}\n\nexport interface FilterRuleRowProps {\n  rule: FilterRule\n  /**\n   * Defaults to `FilterValueInput`. A component, not a render function, so the\n   * memoized row keeps its props stable.\n   */\n  valueInput?: React.ComponentType<FilterValueSlotProps>\n  className?: string\n}\n\nexport const FilterRuleRow = React.memo(function FilterRuleRow({\n  rule,\n  valueInput: ValueInput = FilterValueInput,\n  className,\n}: FilterRuleRowProps) {\n  const { messages } = useFilterActions()\n  const {\n    field,\n    fields,\n    operators,\n    arity,\n    setField,\n    setOperator,\n    setValue,\n    remove,\n  } = useFilterRule(rule)\n  const valueId = React.useId()\n\n  return (\n    <div\n      data-slot=\"filter-rule-row\"\n      className={cn(\"flex flex-wrap items-center gap-2\", className)}\n    >\n      <FilterFieldSelect\n        field={field}\n        fields={fields}\n        onFieldChange={setField}\n      />\n      <FilterOperatorSelect\n        value={rule.operator}\n        operators={operators}\n        onValueChange={setOperator}\n        focusAfterSelect={valueId}\n        disabled={!field}\n      />\n      {field && arity && arity !== \"none\" && (\n        // Keyed so a new field starts with fresh input state (search, loaded options).\n        <ValueInput\n          key={field.name}\n          id={valueId}\n          rule={rule}\n          field={field}\n          arity={arity}\n          setValue={setValue}\n        />\n      )}\n      {/* Right-aligned so remove buttons line up across rows. */}\n      <div className=\"ml-auto flex items-center gap-1\">\n        <FilterDraftRuleWarnings rule={rule} />\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon-sm\"\n          aria-label={messages.actions.removeRule}\n          onClick={remove}\n        >\n          <XIcon />\n        </Button>\n      </div>\n    </div>\n  )\n})\n",
      "type": "registry:component",
      "target": "@components/filter/filter-rule-row.tsx"
    },
    {
      "path": "registry/radix/filter/filter-value-input.tsx",
      "content": "\"use client\"\n\nimport type * as React from \"react\"\nimport type { FieldTypeId } from \"@querycn/filter-core\"\n\nimport { BooleanValueInput } from \"@/registry/radix/filter/filter-boolean-value-input\"\nimport { DateValueInput } from \"@/registry/radix/filter/filter-date-value-input\"\nimport { DateTimeValueInput } from \"@/registry/radix/filter/filter-datetime-value-input\"\nimport type { FilterValueSlotProps } from \"@/registry/radix/filter/filter-rule-row\"\nimport { SelectValueInput } from \"@/registry/radix/filter/filter-select-value-input\"\nimport { TimeValueInput } from \"@/registry/radix/filter/filter-time-value-input\"\nimport {\n  NumberValueInput,\n  TextValueInput,\n} from \"@/registry/radix/filter/filter-text-value-input\"\n\nexport type FilterValueInputs = Partial<\n  Record<FieldTypeId, React.ComponentType<FilterValueSlotProps>>\n>\n\n/** Value input per field type; a custom field type adds its own entry. */\nexport const filterValueInputs: FilterValueInputs = {\n  text: TextValueInput,\n  number: NumberValueInput,\n  boolean: BooleanValueInput,\n  select: SelectValueInput,\n  multiSelect: SelectValueInput,\n  date: DateValueInput,\n  datetime: DateTimeValueInput,\n  time: TimeValueInput,\n}\n\nexport interface FilterValueInputProps extends FilterValueSlotProps {\n  /** Overrides by field type, e.g. `{ ...filterValueInputs, rating: RatingInput }`. */\n  inputs?: FilterValueInputs\n}\n\n/**\n * Picks the input for the rule's field type; types without one get a text\n * input, which handles single and range values only.\n */\nexport function FilterValueInput({\n  inputs = filterValueInputs,\n  ...props\n}: FilterValueInputProps) {\n  const { type } = props.field\n  // Own keys only: a custom type id like \"toString\" must not hit Object.prototype.\n  const Input = Object.hasOwn(inputs, type) ? inputs[type] : undefined\n  return Input ? <Input {...props} /> : <TextValueInput {...props} />\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-value-input.tsx"
    },
    {
      "path": "registry/radix/filter/filter-text-value-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { getFieldType, type FilterValue } from \"@querycn/filter-core\"\nimport { useFilterActions } from \"@querycn/filter-react\"\n\nimport { cn } from \"cn\"\nimport { Input } from \"@/registry/radix/ui/input\"\nimport type { FilterValueSlotProps } from \"@/registry/radix/filter/filter-rule-row\"\n\nconst toText = (value: FilterValue | undefined): string =>\n  value === null || value === undefined || typeof value === \"object\"\n    ? \"\"\n    : String(value)\n\nfunction TextLikeInput({\n  id,\n  rule,\n  field,\n  arity,\n  setValue,\n}: FilterValueSlotProps) {\n  const { context, messages } = useFilterActions()\n  // Raw text goes into the draft; the field type parses it on apply, so \"1.\" can be typed.\n  const invalid = (text: string) =>\n    text.trim() !== \"\" &&\n    getFieldType(field, context.registry)?.parseValue(text, \"single\") ===\n      undefined\n  const input = (\n    text: string,\n    onText: (text: string) => void,\n    props: React.ComponentProps<\"input\">\n  ) => (\n    <Input\n      value={text}\n      onChange={(event) => onText(event.target.value)}\n      aria-invalid={invalid(text) || undefined}\n      {...props}\n      className={cn(\"h-7\", props.className)}\n    />\n  )\n\n  if (arity === \"range\") {\n    const [from, to] =\n      Array.isArray(rule.value) && rule.value.length === 2\n        ? rule.value.map(toText)\n        : [\"\", \"\"]\n    // A range with one side filled is dropped on apply, so flag the empty side.\n    const missing = (text: string, other: string) =>\n      text.trim() === \"\" && other.trim() !== \"\"\n    return (\n      <div className=\"flex items-center gap-1.5\">\n        {input(from!, (text) => setValue([text, to!]), {\n          id,\n          className: \"w-24\",\n          placeholder: messages.placeholders.from,\n          \"aria-invalid\": invalid(from!) || missing(from!, to!) || undefined,\n          \"aria-label\": `${field.label} ${messages.placeholders.from}`,\n        })}\n        <span aria-hidden className=\"text-sm text-muted-foreground\">\n          {messages.rangeSeparator}\n        </span>\n        {input(to!, (text) => setValue([from!, text]), {\n          className: \"w-24\",\n          placeholder: messages.placeholders.to,\n          \"aria-invalid\": invalid(to!) || missing(to!, from!) || undefined,\n          \"aria-label\": `${field.label} ${messages.placeholders.to}`,\n        })}\n      </div>\n    )\n  }\n\n  if (arity !== \"single\") return null\n  return input(toText(rule.value), setValue, {\n    id,\n    className: \"w-40\",\n    placeholder: messages.placeholders.value,\n    \"aria-label\": field.label,\n  })\n}\n\nexport function TextValueInput(props: FilterValueSlotProps) {\n  return <TextLikeInput {...props} />\n}\n\n// No `inputMode=\"decimal\"`: iOS's decimal keypad has no minus key.\nexport function NumberValueInput(props: FilterValueSlotProps) {\n  return <TextLikeInput {...props} />\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-text-value-input.tsx"
    },
    {
      "path": "registry/radix/filter/filter-boolean-value-input.tsx",
      "content": "\"use client\"\n\nimport { useFilterActions } from \"@querycn/filter-react\"\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/registry/radix/ui/select\"\nimport type { FilterValueSlotProps } from \"@/registry/radix/filter/filter-rule-row\"\n\nexport function BooleanValueInput({\n  id,\n  rule,\n  field,\n  setValue,\n}: FilterValueSlotProps) {\n  const { messages } = useFilterActions()\n  return (\n    <Select\n      value={typeof rule.value === \"boolean\" ? String(rule.value) : \"\"}\n      onValueChange={(next) => {\n        // Inside a form Radix mirrors the value into a hidden <select>, which can report \"\".\n        if (next === \"true\" || next === \"false\") setValue(next === \"true\")\n      }}\n    >\n      <SelectTrigger\n        id={id}\n        size=\"sm\"\n        aria-label={field.label}\n        className=\"w-28\"\n      >\n        <SelectValue placeholder={messages.placeholders.value} />\n      </SelectTrigger>\n      <SelectContent position=\"popper\" align=\"start\">\n        <SelectItem value=\"true\">{messages.boolean.true}</SelectItem>\n        <SelectItem value=\"false\">{messages.boolean.false}</SelectItem>\n      </SelectContent>\n    </Select>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-boolean-value-input.tsx"
    },
    {
      "path": "registry/radix/filter/filter-select-value-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport type { FilterValue } from \"@querycn/filter-core\"\nimport { useFieldOptions, useFilterActions } from \"@querycn/filter-react\"\n\nimport { Badge } from \"@/registry/radix/ui/badge\"\nimport { FilterCombobox } from \"@/registry/radix/filter/filter-combobox\"\nimport type { FilterValueSlotProps } from \"@/registry/radix/filter/filter-rule-row\"\n\nconst toValues = (value: FilterValue): string[] =>\n  value === null\n    ? []\n    : Array.isArray(value)\n      ? value.map(String)\n      : [String(value)]\n\n/**\n * One value, or several for `multi` operators (`in`, `notIn`), from static\n * `options` or `loadOptions`. The row keys it by field, so a new field starts fresh.\n */\nexport function SelectValueInput({\n  id,\n  rule,\n  field,\n  arity,\n  setValue,\n}: FilterValueSlotProps) {\n  const { messages } = useFilterActions()\n  const [open, setOpen] = React.useState(false)\n  const multiple = arity === \"multi\"\n  const selected = React.useMemo(() => toValues(rule.value), [rule.value])\n  const { options, loading, error, query, search, retry, getLabel } =\n    useFieldOptions(field, { selected, enabled: open })\n  const [first] = selected\n\n  return (\n    <FilterCombobox\n      id={id}\n      aria-label={field.label}\n      label={\n        first === undefined ? undefined : selected.length > 1 ? (\n          <span className=\"flex min-w-0 items-center gap-1\">\n            <span className=\"truncate\">{getLabel(first)}</span>\n            <Badge variant=\"secondary\" className=\"shrink-0\">\n              {messages.counts.more(selected.length - 1)}\n            </Badge>\n          </span>\n        ) : (\n          getLabel(first)\n        )\n      }\n      placeholder={messages.placeholders.value}\n      options={options}\n      selected={selected}\n      multiple={multiple}\n      onSelectedChange={(values) =>\n        setValue(multiple ? values : (values[0] ?? null))\n      }\n      search={query}\n      onSearchChange={search}\n      emptyText={messages.empty.options}\n      loading={loading}\n      error={error !== null}\n      onRetry={retry}\n      onOpenChange={setOpen}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-select-value-input.tsx"
    },
    {
      "path": "registry/radix/filter/filter-date-value-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { parseDateOnly, toDateOnly } from \"@querycn/filter-core\"\nimport { useFilterActions } from \"@querycn/filter-react\"\nimport { Direction } from \"radix-ui\"\nimport { CalendarIcon } from \"lucide-react\"\nimport type { DateRange } from \"react-day-picker\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport { Calendar } from \"@/registry/radix/ui/calendar\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/radix/ui/popover\"\nimport type { FilterValueSlotProps } from \"@/registry/radix/filter/filter-rule-row\"\nimport { useDateOnlyFormat } from \"@/registry/shared/filter/filter-date-format\"\n\nconst toDay = (value: string | undefined) =>\n  (value && parseDateOnly(value)) || undefined\n\nconst toText = (date: Date | undefined) => (date && toDateOnly(date)) || \"\"\n\n/** Days as `YYYY-MM-DD` in local time, so the picked day never shifts across time zones. */\nexport function DateValueInput({\n  id,\n  rule,\n  field,\n  arity,\n  setValue,\n}: FilterValueSlotProps) {\n  const { messages } = useFilterActions()\n  // The calendar's arrows and weeks follow the library's reading direction.\n  const dir = Direction.useDirection()\n  const [open, setOpen] = React.useState(false)\n  const formatDateOnly = useDateOnlyFormat()\n  const range = arity === \"range\"\n  const [from, to] = (\n    Array.isArray(rule.value) ? rule.value : [rule.value]\n  ).map((value) =>\n    typeof value === \"string\" && value !== \"\" ? value : undefined\n  )\n  const label = range\n    ? from || to\n      ? `${from ? formatDateOnly(from) : \"…\"} ${messages.rangeSeparator} ${to ? formatDateOnly(to) : \"…\"}`\n      : undefined\n    : from && formatDateOnly(from)\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          id={id}\n          variant=\"outline\"\n          size=\"sm\"\n          className={cn(\n            \"justify-start text-sm font-normal\",\n            range ? \"w-56\" : \"w-40\"\n          )}\n        >\n          <CalendarIcon className=\"text-muted-foreground\" />\n          <span className=\"sr-only\">{field.label}: </span>\n          <span className={cn(\"truncate\", !label && \"text-muted-foreground\")}>\n            {label || messages.placeholders.date}\n          </span>\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent dir={dir} className=\"w-auto p-0\" align=\"start\">\n        {range ? (\n          <Calendar\n            dir={dir}\n            mode=\"range\"\n            defaultMonth={toDay(from)}\n            selected={{ from: toDay(from), to: toDay(to) }}\n            onSelect={(next: DateRange | undefined) =>\n              setValue([toText(next?.from), toText(next?.to)])\n            }\n          />\n        ) : (\n          <Calendar\n            dir={dir}\n            mode=\"single\"\n            // Clicking the picked day again keeps it instead of clearing the rule.\n            required\n            defaultMonth={toDay(from)}\n            selected={toDay(from)}\n            onSelect={(next: Date) => {\n              setValue(toText(next))\n              setOpen(false)\n            }}\n          />\n        )}\n      </PopoverContent>\n    </Popover>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-date-value-input.tsx"
    },
    {
      "path": "registry/radix/filter/filter-datetime-value-input.tsx",
      "content": "\"use client\"\n\nimport { parseDateOnly, toDateOnly } from \"@querycn/filter-core\"\nimport { useFilterActions } from \"@querycn/filter-react\"\nimport { Direction } from \"radix-ui\"\nimport { CalendarIcon } from \"lucide-react\"\n\nimport { Calendar } from \"@/registry/radix/ui/calendar\"\nimport {\n  PickerField,\n  PickerFooter,\n  RangePickerField,\n  type PickerPanel,\n} from \"@/registry/radix/filter/filter-picker-field\"\nimport type { FilterValueSlotProps } from \"@/registry/radix/filter/filter-rule-row\"\nimport {\n  currentDateTime,\n  splitDateTime,\n  useDateTimeFormat,\n} from \"@/registry/shared/filter/filter-date-format\"\nimport { TimeColumns } from \"@/registry/shared/filter/filter-time-panel\"\nimport { toTextRange } from \"@/registry/shared/filter/filter-picker-field-parts\"\n\nconst toDay = (day: string | undefined) =>\n  (day && parseDateOnly(day)) || undefined\n\n/**\n * A moment as the local `YYYY-MM-DDTHH:mm`, picked from a calendar and hour\n * and minute columns. A zoned value (e.g. \"…Z\") shows in local time.\n */\nexport function DateTimeValueInput({\n  id,\n  rule,\n  field,\n  arity,\n  setValue,\n}: FilterValueSlotProps) {\n  const { messages } = useFilterActions()\n  // The calendar's arrows and weeks follow the library's reading direction.\n  const dir = Direction.useDirection()\n  const format = useDateTimeFormat()\n  const panel: PickerPanel = (value, onChange, onOk) => {\n    const { day, time } = splitDateTime(value)\n    return (\n      <>\n        <div className=\"flex\">\n          <Calendar\n            dir={dir}\n            mode=\"single\"\n            // Clicking the picked day again keeps it instead of clearing it.\n            required\n            defaultMonth={toDay(day)}\n            selected={toDay(day)}\n            onSelect={(date: Date) =>\n              onChange(`${toDateOnly(date)}T${time ?? \"00:00\"}`)\n            }\n          />\n          {/* As tall as the calendar, whatever the month. */}\n          <div className=\"relative w-28 border-s\">\n            <TimeColumns\n              className=\"absolute inset-0 h-auto\"\n              value={time ?? \"\"}\n              onChange={(next) =>\n                onChange(`${day ?? currentDateTime().slice(0, 10)}T${next}`)\n              }\n              onConfirm={onOk}\n              labels={messages.placeholders}\n            />\n          </div>\n        </div>\n        <PickerFooter\n          okDisabled={!day}\n          onOk={onOk}\n          onNow={() => {\n            onChange(currentDateTime())\n            onOk()\n          }}\n        />\n      </>\n    )\n  }\n  return arity === \"range\" ? (\n    <RangePickerField\n      id={id}\n      label={field.label}\n      value={toTextRange(rule.value)}\n      onChange={setValue}\n      format={format}\n      icon={CalendarIcon}\n      panel={panel}\n      sideClassName=\"min-w-11\"\n    />\n  ) : (\n    <PickerField\n      id={id}\n      label={field.label}\n      value={typeof rule.value === \"string\" ? rule.value : \"\"}\n      onChange={setValue}\n      format={format}\n      placeholder={messages.placeholders.datetime}\n      icon={CalendarIcon}\n      panel={panel}\n      className=\"w-48\"\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-datetime-value-input.tsx"
    },
    {
      "path": "registry/radix/filter/filter-time-value-input.tsx",
      "content": "\"use client\"\n\nimport { useFilterActions } from \"@querycn/filter-react\"\nimport { ClockIcon } from \"lucide-react\"\n\nimport {\n  PickerField,\n  PickerFooter,\n  RangePickerField,\n  type PickerPanel,\n} from \"@/registry/radix/filter/filter-picker-field\"\nimport type { FilterValueSlotProps } from \"@/registry/radix/filter/filter-rule-row\"\nimport { TimeColumns } from \"@/registry/shared/filter/filter-time-panel\"\nimport { toTextRange } from \"@/registry/shared/filter/filter-picker-field-parts\"\nimport { currentTime } from \"@/registry/shared/filter/filter-time-parts\"\n\nconst asIs = (value: string) => value\n\n/** A time of day as `HH:mm`, picked from hour and minute columns. */\nexport function TimeValueInput({\n  id,\n  rule,\n  field,\n  arity,\n  setValue,\n}: FilterValueSlotProps) {\n  const { messages } = useFilterActions()\n  const panel: PickerPanel = (value, onChange, onOk) => (\n    <>\n      <TimeColumns\n        autoFocus\n        value={value}\n        onChange={onChange}\n        onConfirm={onOk}\n        labels={messages.placeholders}\n      />\n      <PickerFooter\n        okDisabled={!value}\n        onOk={onOk}\n        onNow={() => {\n          onChange(currentTime())\n          onOk()\n        }}\n      />\n    </>\n  )\n  return arity === \"range\" ? (\n    <RangePickerField\n      id={id}\n      label={field.label}\n      value={toTextRange(rule.value)}\n      onChange={setValue}\n      format={asIs}\n      icon={ClockIcon}\n      panel={panel}\n      sideClassName=\"w-11\"\n    />\n  ) : (\n    <PickerField\n      id={id}\n      label={field.label}\n      value={typeof rule.value === \"string\" ? rule.value : \"\"}\n      onChange={setValue}\n      format={asIs}\n      placeholder={messages.placeholders.time}\n      icon={ClockIcon}\n      panel={panel}\n      className=\"w-32\"\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-time-value-input.tsx"
    },
    {
      "path": "registry/radix/filter/filter-picker-field.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Direction } from \"radix-ui\"\nimport { useFilterActions } from \"@querycn/filter-react\"\nimport { ArrowRightIcon, type LucideIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"@/registry/radix/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/radix/ui/popover\"\nimport {\n  pickerRangeFieldClassName,\n  pickerRangeSideClassName,\n} from \"@/registry/shared/filter/filter-picker-field-parts\"\n\n/** What a picker's popover shows; `onOk` closes it (or moves on to a range's end). */\nexport type PickerPanel = (\n  value: string,\n  onChange: (value: string) => void,\n  onOk: () => void\n) => React.ReactNode\n\ninterface PickerFieldBaseProps {\n  id?: string\n  label: string\n  format: (value: string) => string\n  icon: LucideIcon\n  panel: PickerPanel\n}\n\n/** Now and OK under a picker. */\nexport function PickerFooter({\n  onNow,\n  onOk,\n  okDisabled,\n}: {\n  onNow: () => void\n  onOk: () => void\n  okDisabled: boolean\n}) {\n  const { messages } = useFilterActions()\n  return (\n    <div className=\"flex items-center justify-between gap-2 border-t p-1.5\">\n      <Button variant=\"link\" size=\"sm\" className=\"h-7 px-1.5\" onClick={onNow}>\n        {messages.actions.now}\n      </Button>\n      <Button size=\"sm\" className=\"h-7\" disabled={okDisabled} onClick={onOk}>\n        {messages.actions.ok}\n      </Button>\n    </div>\n  )\n}\n\n/** A button showing the value, opening `panel` in a popover. */\nexport function PickerField({\n  id,\n  label,\n  value,\n  onChange,\n  format,\n  placeholder,\n  icon: Icon,\n  panel,\n  className,\n}: PickerFieldBaseProps & {\n  value: string\n  onChange: (value: string) => void\n  placeholder: string\n  className?: string\n}) {\n  const dir = Direction.useDirection()\n  const [open, setOpen] = React.useState(false)\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          id={id}\n          variant=\"outline\"\n          size=\"sm\"\n          className={cn(\n            \"justify-between text-sm font-normal tabular-nums\",\n            className\n          )}\n        >\n          <span className=\"sr-only\">{label}: </span>\n          <span className={cn(\"truncate\", !value && \"text-muted-foreground\")}>\n            {value ? format(value) : placeholder}\n          </span>\n          <Icon className=\"text-muted-foreground\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent dir={dir} className=\"w-auto gap-0 p-0\" align=\"start\">\n        {panel(value, onChange, () => setOpen(false))}\n      </PopoverContent>\n    </Popover>\n  )\n}\n\ntype Side = \"from\" | \"to\"\n\n/** Both ends of a range in one field; OK on the start moves on to the end. */\nexport function RangePickerField({\n  id,\n  label,\n  value: [from, to],\n  onChange,\n  format,\n  icon: Icon,\n  panel,\n  sideClassName,\n}: PickerFieldBaseProps & {\n  value: [string, string]\n  onChange: (value: [string, string]) => void\n  sideClassName?: string\n}) {\n  const dir = Direction.useDirection()\n  const { messages } = useFilterActions()\n  const [open, setOpen] = React.useState<Side | null>(null)\n\n  const side = (name: Side, value: string, other: string) => (\n    <Popover\n      open={open === name}\n      onOpenChange={(next) =>\n        setOpen((current) => (next ? name : current === name ? null : current))\n      }\n    >\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          id={name === \"from\" ? id : undefined}\n          aria-label={`${label} ${messages.placeholders[name]}`}\n          data-active={open === name || undefined}\n          // A range with one end picked is dropped on apply, so flag the empty end.\n          data-invalid={(value === \"\" && other !== \"\") || undefined}\n          className={cn(pickerRangeSideClassName, sideClassName)}\n        >\n          <span className={cn(!value && \"text-muted-foreground\")}>\n            {value ? format(value) : messages.placeholders[name]}\n          </span>\n        </button>\n      </PopoverTrigger>\n      <PopoverContent\n        dir={dir}\n        className=\"w-auto gap-0 p-0\"\n        align=\"start\"\n        // Moving on to the end: focus goes to its picker, not back here.\n        onCloseAutoFocus={(event) => {\n          if (name === \"from\" && open === \"to\") event.preventDefault()\n        }}\n      >\n        {panel(\n          value,\n          (next) => onChange(name === \"from\" ? [next, to] : [from, next]),\n          () => setOpen(name === \"from\" ? \"to\" : null)\n        )}\n      </PopoverContent>\n    </Popover>\n  )\n\n  return (\n    <div className={pickerRangeFieldClassName}>\n      {side(\"from\", from, to)}\n      <ArrowRightIcon\n        aria-hidden\n        className=\"size-3.5 shrink-0 text-muted-foreground rtl:rotate-180\"\n      />\n      {side(\"to\", to, from)}\n      <Icon aria-hidden className=\"size-4 shrink-0 text-muted-foreground\" />\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-picker-field.tsx"
    },
    {
      "path": "registry/shared/filter/filter-date-format.ts",
      "content": "import { useSyncExternalStore } from \"react\"\nimport { parseDateOnly, toDateOnly } from \"@querycn/filter-core\"\n\nconst subscribe = () => () => {}\n\n/**\n * `false` on the server and while hydrating, then `true`. Output that depends\n * on the browser (locale, time zone) waits for it, so server and client HTML match.\n */\nexport function useHydrated(): boolean {\n  return useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false\n  )\n}\n\n/** A `YYYY-MM-DD` day in the user's locale, e.g. \"Sep 25, 2026\"; anything else as is. */\nexport function formatDateOnly(value: string): string {\n  const date = parseDateOnly(value)\n  return date\n    ? date.toLocaleDateString(undefined, { dateStyle: \"medium\" })\n    : value\n}\n\nconst asIs = (value: string) => value\n\n/** `formatDateOnly` once hydrated; the raw day before. */\nexport function useDateOnlyFormat(): (value: string) => string {\n  return useHydrated() ? formatDateOnly : asIs\n}\n\nconst ZONED = /T.*(?:Z|[+-]\\d{2}:?\\d{2})$/i\nconst pad = (part: number) => String(part).padStart(2, \"0\")\n\n/** A zoned date-time (e.g. from `toISOString`) as the local `YYYY-MM-DDTHH:mm`; other values as is. */\nexport function toDateTimeLocal(value: string): string {\n  const date = ZONED.test(value) ? new Date(value) : null\n  if (!date || Number.isNaN(date.getTime())) return value\n  return `${toDateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`\n}\n\nconst LOCAL_DATE_TIME = /^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2})/\n\n/** `\"2026-09-28T14:05\"` (or a zoned value, in local time) → its day and `HH:mm`. */\nexport function splitDateTime(value: string): { day?: string; time?: string } {\n  const match = LOCAL_DATE_TIME.exec(toDateTimeLocal(value))\n  return match ? { day: match[1], time: match[2] } : {}\n}\n\n/** A date-time as its day in the user's locale and a 24-hour time, e.g. \"09/28/2026 14:05\". */\nexport function formatDateTime(value: string): string {\n  const { day, time } = splitDateTime(value)\n  const date = day ? parseDateOnly(day) : null\n  if (!date || !time) return value\n  const text = date.toLocaleDateString(undefined, {\n    year: \"numeric\",\n    month: \"2-digit\",\n    day: \"2-digit\",\n  })\n  return `${text} ${time}`\n}\n\nconst asDateTime = (value: string) => value.replace(\"T\", \" \")\n\n/** `formatDateTime` once hydrated; the raw value, `T` spaced out, before. */\nexport function useDateTimeFormat(): (value: string) => string {\n  return useHydrated() ? formatDateTime : asDateTime\n}\n\n/** The browser's local date and time as `YYYY-MM-DDTHH:mm`, for \"Now\". */\nexport function currentDateTime(): string {\n  const now = new Date()\n  return `${toDateOnly(now)}T${pad(now.getHours())}:${pad(now.getMinutes())}`\n}\n",
      "type": "registry:lib",
      "target": "@components/filter/filter-date-format.ts"
    },
    {
      "path": "registry/shared/filter/filter-time-parts.ts",
      "content": "import type { FilterValue } from \"@querycn/filter-core\"\n\nimport { currentDateTime } from \"@/registry/shared/filter/filter-date-format\"\n\nconst pad = (part: number) => String(part).padStart(2, \"0\")\n\nexport const HOURS = Array.from({ length: 24 }, (_, hour) => pad(hour))\nexport const MINUTES = Array.from({ length: 60 }, (_, minute) => pad(minute))\n\nexport type TimePart = \"hour\" | \"minute\"\n\nconst TIME = /^(\\d{2}):(\\d{2})$/\n\n/** `\"09:30\"` → `{ hour: \"09\", minute: \"30\" }`; anything else has neither part. */\nexport function splitTime(value: FilterValue | undefined): {\n  hour?: string\n  minute?: string\n} {\n  const match = typeof value === \"string\" ? TIME.exec(value) : null\n  return match ? { hour: match[1], minute: match[2] } : {}\n}\n\n/** The time with one part picked; the other part starts at `00`, so every pick is a valid time. */\nexport function withTimePart(\n  value: FilterValue | undefined,\n  part: TimePart,\n  next: string\n): string {\n  const { hour = \"00\", minute = \"00\" } = splitTime(value)\n  return part === \"hour\" ? `${next}:${minute}` : `${hour}:${next}`\n}\n\n/** The browser's local time, for \"Now\". */\nexport const currentTime = () => currentDateTime().slice(11)\n",
      "type": "registry:lib",
      "target": "@components/filter/filter-time-parts.ts"
    },
    {
      "path": "registry/shared/filter/filter-time-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"cn\"\nimport {\n  HOURS,\n  MINUTES,\n  splitTime,\n  withTimePart,\n  type TimePart,\n} from \"@/registry/shared/filter/filter-time-parts\"\n\ninterface TimeColumnProps {\n  label: string\n  items: string[]\n  selected: string | undefined\n  onPick: (item: string) => void\n  onConfirm: () => void\n  autoFocus?: boolean\n}\n\n/** One scrolling column of the picker, a listbox driven by ↑/↓, Home/End and Enter. */\nfunction TimeColumn({\n  label,\n  items,\n  selected,\n  onPick,\n  onConfirm,\n  autoFocus,\n}: TimeColumnProps) {\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const optionsId = React.useId()\n  const optionId = (item: string) => `${optionsId}-${item}`\n  const opening = React.useRef(selected)\n\n  // Opens with the picked item at the top, like a wheel.\n  React.useLayoutEffect(() => {\n    const list = listRef.current\n    const option =\n      opening.current &&\n      list?.querySelector<HTMLElement>(`[data-value=\"${opening.current}\"]`)\n    if (list && option) list.scrollTop = option.offsetTop - 4\n  }, [])\n\n  // An effect, not the attribute: it runs before a parent dialog focuses itself.\n  React.useEffect(() => {\n    if (autoFocus) listRef.current?.focus({ preventScroll: true })\n  }, [autoFocus])\n\n  const pick = (item: string) => {\n    onPick(item)\n    listRef.current\n      ?.querySelector<HTMLElement>(`[data-value=\"${item}\"]`)\n      // jsdom has no scrollIntoView.\n      ?.scrollIntoView?.({ block: \"nearest\" })\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    const index = selected ? items.indexOf(selected) : -1\n    const next =\n      event.key === \"ArrowDown\"\n        ? items[Math.min(index + 1, items.length - 1)]\n        : event.key === \"ArrowUp\"\n          ? items[Math.max(index - 1, 0)]\n          : event.key === \"Home\"\n            ? items[0]\n            : event.key === \"End\"\n              ? items.at(-1)\n              : undefined\n    if (next) pick(next)\n    else if (event.key === \"Enter\" && selected) onConfirm()\n    else return\n    event.preventDefault()\n  }\n\n  return (\n    <div\n      ref={listRef}\n      role=\"listbox\"\n      aria-label={label}\n      tabIndex={0}\n      aria-activedescendant={selected ? optionId(selected) : undefined}\n      onKeyDown={onKeyDown}\n      className=\"relative h-full w-14 overflow-y-auto overscroll-contain p-1 outline-none [scrollbar-width:thin] focus-visible:bg-accent/40\"\n    >\n      {items.map((item) => (\n        <div\n          key={item}\n          id={optionId(item)}\n          role=\"option\"\n          aria-selected={item === selected}\n          data-value={item}\n          onClick={() => pick(item)}\n          className={cn(\n            \"flex h-7 cursor-default items-center justify-center rounded-sm text-sm tabular-nums select-none hover:bg-accent\",\n            item === selected &&\n              \"bg-primary/10 font-medium text-primary hover:bg-primary/15\"\n          )}\n        >\n          {item}\n        </div>\n      ))}\n      {/* Room to scroll the last items up to the top. */}\n      <div aria-hidden className=\"h-48\" />\n    </div>\n  )\n}\n\nexport interface TimeColumnsProps {\n  value: string\n  onChange: (time: string) => void\n  /** Enter in a column, once a time is picked. */\n  onConfirm: () => void\n  labels: Record<TimePart, string>\n  /** Focus the hour column on open, when nothing else in the picker should take it. */\n  autoFocus?: boolean\n  className?: string\n}\n\n/** Hour and minute columns side by side; picking one part starts the other at `00`. */\nexport function TimeColumns({\n  value,\n  onChange,\n  onConfirm,\n  labels,\n  autoFocus,\n  className,\n}: TimeColumnsProps) {\n  const parts = splitTime(value)\n  const column = (part: TimePart, items: string[]) => (\n    <TimeColumn\n      label={labels[part]}\n      items={items}\n      selected={parts[part]}\n      onPick={(item) => onChange(withTimePart(value, part, item))}\n      onConfirm={onConfirm}\n      autoFocus={autoFocus && part === \"hour\"}\n    />\n  )\n  return (\n    <div className={cn(\"flex h-56 divide-x\", className)}>\n      {column(\"hour\", HOURS)}\n      {column(\"minute\", MINUTES)}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/filter/filter-time-panel.tsx"
    },
    {
      "path": "registry/shared/filter/filter-picker-field-parts.ts",
      "content": "import type { FilterValue } from \"@querycn/filter-core\"\n\n/** A range's two values as text, or two blanks. */\nexport function toTextRange(value: FilterValue): [string, string] {\n  return Array.isArray(value) && value.length === 2\n    ? [String(value[0] ?? \"\"), String(value[1] ?? \"\")]\n    : [\"\", \"\"]\n}\n\n/** A range field: looks like one input holding both ends. */\nexport const pickerRangeFieldClassName =\n  \"flex h-7 w-fit items-center gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors focus-within:border-ring dark:bg-input/30\"\n\n/** One end of a range field, underlined while its picker is open. */\nexport const pickerRangeSideClassName =\n  \"relative flex h-full shrink-0 items-center text-start tabular-nums outline-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary after:opacity-0 after:transition-opacity focus-visible:after:opacity-100 data-active:after:opacity-100 data-invalid:text-destructive\"\n",
      "type": "registry:lib",
      "target": "@components/filter/filter-picker-field-parts.ts"
    },
    {
      "path": "registry/shared/filter/filter-rule-summary.ts",
      "content": "import {\n  getOperatorArity,\n  getOperatorLabel,\n  type FieldDefinition,\n  type FilterRule,\n} from \"@querycn/filter-core\"\nimport { useAppliedFilter, useFieldOptions } from \"@querycn/filter-react\"\n\nimport {\n  useDateOnlyFormat,\n  useDateTimeFormat,\n} from \"@/registry/shared/filter/filter-date-format\"\n\nexport interface RuleSummary {\n  field: string\n  operator: string\n  /** `undefined` for operators without a value, e.g. \"is empty\". */\n  value: string | undefined\n}\n\n/** An applied rule in words, e.g. \"Status\", \"is any of\", \"Active, Archived\". */\nexport function useRuleSummary(\n  rule: FilterRule,\n  field: FieldDefinition\n): RuleSummary {\n  const { context, messages } = useAppliedFilter()\n  const formatDateOnly = useDateOnlyFormat()\n  const formatDateTime = useDateTimeFormat()\n  const items = (\n    rule.value === null\n      ? []\n      : Array.isArray(rule.value)\n        ? rule.value\n        : [rule.value]\n  ).map((item) => (typeof item === \"boolean\" ? item : String(item)))\n  const { getLabel } = useFieldOptions(field, {\n    selected: items.filter((item) => typeof item === \"string\"),\n    enabled: false,\n  })\n\n  const text = (item: string | boolean) =>\n    typeof item === \"boolean\"\n      ? messages.boolean[item ? \"true\" : \"false\"]\n      : field.type === \"date\"\n        ? formatDateOnly(item)\n        : field.type === \"datetime\"\n          ? formatDateTime(item)\n          : getLabel(item)\n  const arity =\n    rule.operator === null\n      ? undefined\n      : getOperatorArity(rule.operator, context.registry.operators)\n\n  return {\n    field: field.label,\n    operator:\n      rule.operator === null\n        ? \"\"\n        : getOperatorLabel(messages, rule.operator, field.type),\n    value:\n      arity === \"none\" || items.length === 0\n        ? undefined\n        : items\n            .map(text)\n            .join(arity === \"range\" ? ` ${messages.rangeSeparator} ` : \", \"),\n  }\n}\n",
      "type": "registry:lib",
      "target": "@components/filter/filter-rule-summary.ts"
    }
  ],
  "type": "registry:block"
}